先来看效果展示:买下 Steam 所有游戏要花多少钱?

背景

最近 Steam 玩得比较多,早晨突然想到一个有趣的问题:买下 Steam 所有游戏要花多少钱?

去 Google 了一下,发现国外有个网站做了计算,但是 2014 年底就停止更新了。研究了一下代码和 Steam API,自己做了一个网站来玩。

虽然没什么技术含量,但是很好的展示了如何把一个点子变成现实,所以记录下来。

技能和工具

这个网站非常简单,涉及到的技术只要初步掌握即可实现。

以下是我用到的技能和工具,你可以根据自己情况调整

技能:

  • Python
  • Node.js
  • 基本的 HTML、CSS 和 JS
  • 基本的 Linux 技能
  • 基本的 Nginx 技能
  • 翻墙能力
  • 会用 GitHub

工具:

  • 一台 VPS
  • 一个域名
  • 一个编辑器(我用的 Sublime Text 3)

调查

首先去 Google 一下“How much to buy all steam games”,搜到这个网站:Buy All of Steam,截图如下

网站截图

哇,九万多美元!真不少。

再往下看,最后一次更新时间是 2014 年光棍节。

ARE YOU KIDDING ME?

是不是作者在光棍节脱单了所以放弃了 Steam?

继续往下看,网站还给出了计算方法,非常简单:

1
2
3
4
5
6
7
8
from steamapiwrapper.SteamGames import Games
games = Games()
full_price = 0.0
discounted_price = 0.0
for game in games.get_all('US'):
if game.price != 0:
discounted_price += game.discounted_price
full_price += game.full_price

哇真的好简单!

然后我去看了一下这个steamapiwrapper库,

steamapiwrapper

2 years ago

2 years ago

2 years ago

掀桌

这就是网站作者自己写的库吧!一定是脱团之后弃坑了吧!!!

好吧,关掉网页,回到 Google 继续往下看。

嗯……没了。

其他的网页都是一些统计性质的文章,Steam 更新频率极高,这类文章基本上是一发表就过时。

怎么办?

作为无所不能的程序员,当然是自己写一个啦!既然两年前能实现,两年后一定也能搞定!

接受挑战

看看我们收集到了什么有用的东西:

  • 一段计算代码
  • 一个 Steam API 库

那就从这里开始吧。

修改代码

以下代码不包含任何最佳实践,Just For Fun!

首先来看看这段两年前的代码还能否运行,如果能,那我们只要写个网页展示就可以了。

steamapiwrapper没有上传到 pip,所以我们只能下载代码到本地。

首先登陆 VPS:

1
ssh root@xxx.xxx.xx.x

提示:本文的命令和代码是意识流,重在介绍思想和流程,具体的细节请自行 Google(别百度,百度一下你就被坑)。

然后用virtualenv创建 Python 虚拟环境,不影响本机的 Python 配置:

1
2
3
4
5
$ mkdir /steamtuhao
$ cd /steamtuhao
$ pip install virtualenv
$ virtualenv venv
$ virtualenv -p /usr/bin/python2.7 venv

执行完会在根目录下的steamtuhao目录中创建一个 Python 虚拟环境,并且指定 Python 版本为 2.7(steamapiwrapper基于 Python 2.x 开发)。

然后开启虚拟环境,下载第三方库:

1
2
3
4
5
$ source venv/bin/activate
$ git clone git@github.com:naiyt/steamapiwrapper.git
$ cp -avr steamapiwrapper/steamapiwrapper ./temp
$ rm -rf steamapiwrapper
$ mv temp steamapiwrapper

最后三行是不是看懵了?GitHub 克隆下来的库并不能直接导入 Python 中,需要把里面真正的 Python 包复制出来。所以这里的操作其实是:复制出来我们要用的包、删掉整个项目、重命名包。

最后新建一个文件,把网站中提到的那段代码复制进去:

1
2
3
4
5
6
7
8
9
# 需要复制的代码
from steamapiwrapper.SteamGames import Games
games = Games()
full_price = 0.0
discounted_price = 0.0
for game in games.get_all('US'):
if game.price != 0:
discounted_price += game.discounted_price
full_price += game.full_price
1
2
3
$ vim calTotalPrices.py
# 进入 vim
# 粘贴上面的代码并保存

OK,运行一下试试:

1
$ python calTotalPrices.py

报错了。

具体的错误信息我忘了保存,大概就是说 JSON 不能解析None。打开出错的SteamGames.py定位过去看下,发现调用了一个_open_url函数,搜索一下这个函数看看…………

没找到。

这哥们绝对是恋爱了,否则不可能犯这么弱智的错误。

经过@Ralph-Wang 提醒,发现_open_url是继承自SteamBase.py中的SteamAPI类。那应该和下面提到的问题一样,因为 URL 里面编码了参数,导致请求返回 null。

好吧,看上下文,这里应该是请求一个 URL 并解析返回的 JSON 内容。

那我们直接用requests这个库就行。

1
$ pip install requests

然后修改SteamGames.py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 文件头部 import 进来
import requests

...

# 把两处 _open_url 都改过来
def _get_games_from(self, url):
"""Generator to create the actual game objects"""
page = requests.get(url).json() # ←第一处

...

def get_ids_and_names(self):
"""
Returns two dicts: one mapping appid->game name, and one game name->appid
TODO: Refactor the code so we don't need to seperate dicts

"""

url = "http://api.steampowered.com/ISteamApps/GetAppList/v2"
url_info = requests.get(url).json() # ←第二处

OK,现在再来跑一下看看:

1
$ python calTotalPrices.py

又报错了。

具体的错误信息我没保存(为什么这句话这么眼熟),反正大概意思就是 JSON 不能解析None。什么?刚才不就是这个错误吗?!

仔细看了一下,错误位置和上次一样,到底是怎么回事?

回答这个问题之前先来了解下请求 URL 时到底发生了什么:

  • 访问 URL
  • 服务器返回 JSON 数据
  • 拿到返回的数据并解析

我们刚才解决的是第一步,访问 URL。现在又出错了,那就说明返回的 JSON 数据有问题。

可以在代码里加一个print page看下,果然是None,也就是说根本就没拿到数据。

怎么回事呢?我们再print url一下,我看到的是这个:

1
http://store.steampowered.com/api/appdetails/?cc=US&appids=5%2C262150%2C7%2C8%2C10%2C20%2C393240%2C30%2C40%2C262190%2C50%2C393270%2C60%2C262210%2C70%2C393290%2C80%2C262230%2C90%2C92%2C262240%2C100%2C393320%2C393330%2C262260&l=english&v=1

appids肯定有问题啊!

print all_ids,从里面拿出来一个 id,手动拼接到上面的 URL 中:

1
http://store.steampowered.com/api/appdetails/?appids=218620&cc=US&l=english&v=1

访问结果

拿到了数据,看来就是 URL 拼接时候出问题了。

看下拼接函数:

1
2
3
4
5
def _create_url(self, appids, cc):
"""Given a list of appids, creates an API url to retrieve them"""
appids = ','.join([str(x) for x in appids])
data = {'appids': appids, 'cc': cc, 'l': 'english', 'v': '1'}
return "http://store.steampowered.com/api/appdetails/?{}".format(urllib.urlencode(data))

为什么要urlencode呢?删掉,直接手动拼接:

1
2
3
4
5
def _create_url(self, appids, cc):
"""Given a list of appids, creates an API url to retrieve them"""
appids = ','.join([str(x) for x in appids])
data = (appids, cc, 'english')
return "http://store.steampowered.com/api/appdetails/?appids=%s&cc=%s&l=%s&v=1" % data

再执行一下,还是报错。

好吧,就是这样的,现在你知道两年前的项目是什么概念了。

刚才我们在浏览器里不是拿到数据了吗?怎么又出问题了?

仔细看下拼接的 URL,发现有个区别:拼接的 URL 里有多个appid,我们刚才只试了一个。

修改测试 URL:

1
http://store.steampowered.com/api/appdetails/?appids=218620,441600&cc=US&l=english&v=1

测试

果然,返回 null。

到底是怎么回事?

再次阅读steamapiwrapper的文档,发现作者提到了一篇文章,说他用文章里的方法重构了 API,我们去看看那篇文章

打开一看,说的就是我们这个 API 啊!往下翻,看到好多两年前的评论,再往下翻,最底部的一条评论是五个月前的,看看说了什么:

评论

热泪盈眶!兄弟你是个好人啊!!不仅发现了这个问题,还给出了解决方法!

&filters=price_overview加到 URL 结尾看看:

1
http://store.steampowered.com/api/appdetails/?appids=218620,441600&cc=US&l=english&v=1&filters=price_overview

测试

热泪盈眶 again!数据出来了,而且正是我们想要的价格数据!

这里做个笔记,返回的数据中currency表示货币种类,initial表示原价,final表示折扣价。哎这游戏怎么这么贵?1999 美元?打开 Steam 搜了一下,是 19.99 美元,明白了,这个数字要除以 100 才是实际价格。

科普:为什么 Steam 要乘以 100?

在很多语言中 0.1 + 0.1 都不等于 0.2,这是因为计算机本身的设计缺陷,无法准确保存浮点数(也就是小数),因此对浮点数做运算会有误差。最简单的解决办法就是把浮点数变成整数进行运算,最终需要展示时再除回小数。

如果你想了解更多浮点数内容,可以阅读逼乎上的答案

下面继续修改代码:

1
2
3
4
5
def _create_url(self, appids, cc):
"""Given a list of appids, creates an API url to retrieve them"""
appids = ','.join([str(x) for x in appids])
data = (appids, cc, 'english')
return "http://store.steampowered.com/api/appdetails/?appids=%s&cc=%s&l=%s&v=1&filters=price_overview" % data

再次运行,又报错了,错误提示不一样了!可喜可贺。

具体的错误提示我忘了(……),反正大概是说Game类初始化时候有问题。

看一下出错位置的代码:

1
2
3
4
for appid in page:
game = Game(page[appid], appid)
if game.success:
yield game

这里的page是一个解析后的 JSON 内容,也就是说它是一个字典。用for循环去遍历的时候,拿到的appid是字典的键,传入Game类生成实例的时候出错了。跳过去看了一下Game类的实现代码,好麻烦,懒得改了,反正已经拿到价格数据,直接返回得了。

1
2
3
4
5
6
def _get_games_from(self, url):
"""Generator to create the actual game objects"""
page = requests.get(url).json()
for game in page:
if page[game]['success'] and page[game]['data']:
yield page[game]['data']['price_overview']

再重复一遍,page 是字典,所以要用方括号去获取内容。

测试的时候发现有时候请求成功但是data是空,所以if中加了一个判断条件。

由于返回的内容改变,我们还需要修改calTotalPrices.py里面的代码:

1
2
3
4
5
6
7
8
9
from steamapiwrapper.SteamGames import Games
games = Games()
full_price = 0.0
discounted_price = 0.0
for game in games.get_all('US'):
if game['initial'] != 0:
discounted_price += game['final']
full_price += game['initial']
print full_price, discounted_price

再次运行程序,这次没有报错,并且一直在输出价格,大功告成!

这一节写了好长,终于能结束了。

验证

代码跑通了,下面就是要检查数据是否正确。

执行:

1
$ python calTotalPrices.py

一开始没问题,过了一会又报错了。

ARE YOU KIDDING ME?

不是没问题了吗?

这时候,经验丰富的同学应该已经想到了一种可能性:API 调用频率限制。

没错,Steam 不是慈善家,API 资源不可能给你无限使用。经过一番研究,发现确实是触发了 API 的限制。一旦访问频率过快,Steam 会直接返回 null。

那么 Steam 的限制到底是多少?

Google 一番之后,发现 Steam 官方没有任何说明。聪明的网友们自己总结出几条规则:

  • 10 秒内最多调用 10 次
  • 5 分钟内最多调用 200 次
  • x 分钟内……

好了好了我明白了,总之一秒调用一次肯定没问题是吧?简单,加个sleep(1)

1
2
3
4
5
6
7
8
import time

...

for url in urls:
for game in self._get_games_from(url):
yield game
time.sleep(1)

加完之后,经验丰富的同学应该又想到了另一个问题:要抓多久?

print len(all_ids),大概有 23000 个 id,代码中self.num = 25,每次请求查询 25 个,需要查询 23000/25 = 1000 次。每次请求睡眠一秒,那就是 1000 多秒,大概 17 分钟。再加上请求本身需要的时间,可能要几十分钟吧。

看起来也可以接受,不过还能优化吗?

仔细看代码中的注释:

1
2
3
4
5
6
7
def __init__(self,num=None):
"""
args:
num -- number of games to query per call. The default 150 should work in most cases.

"""

self.num = 25 if num is None else num

原来默认值是 150 啊,那我们就改成self.num = 150,一下快了 6 倍,好开心。

下面就来正式运行一下,看看能否拿到数据:

1
$ nohup python calTotalPrices.py > result &

咦,怎么出来一个nohup?这是一个新命令,简单来说就是后台执行。这条命令把输出写到result文件中,结尾的&会让进程在后台持续运行,哪怕 ssh 断掉进程也不会中止。

然后等就可以了,什么时候程序执行完了,什么时候拿到结果。

等几分钟就跑完了,看看总价:

1
15031825 14903412

哇,真不少啊!十五万美元!

现在已经解决了我的问题,算出了总价。不过我还想做得更多,能不能让其他人也看到这个数据呢?

当然能,做个网站就可以了。

展示

现在已经拿到数据了,接下来要做的是展示数据。

我们从用户的角度来思考,他们如何查看数据?

  • 访问一个 URL,因此需要注册一个域名
  • 请求会发送到后端服务器,因此需要准备一个 VPS
  • VPS 需要处理请求,因此需要配置 Nginx
  • Nginx 拿到请求之后要反向代理给具体的处理者,因此需要编写一个 Node.js 程序
  • Node.js 程序需要返回一个页面,因此需要编写一个 HTML 页面

OK,就是这些,涉及到很多东西,但是都不难。具体实施的时候顺序稍有不同,我们一步一步说。

注册一个域名

具体教程自己 Google,一般注册域名国内去万网,国外去GoDaddyName

买好域名之后,把域名解析到自己的 VPS IP 地址就可以了。

准备一个 VPS

VPS 是另一个话题,你问我资词哪个?我主要用 Linode 和阿里云。不过要注意,大陆的主机要求域名备案,不备案的域名不能解析到大陆主机。所以如果你域名没备案,去买香港或者新加坡的主机,阿里云有,UCloud 也有,很多家都有。还可以买日本和欧美主机,不过速度比较慢。

编写一个 HTML 页面

由于只需要展示数字,所以直接编写一个带占位符的简单页面就可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>买下 Steam 所有游戏要花多少钱?</title>
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<style type="text/css">
... 省略,可以直接查看我的网站源码
</style>

</head>
<body>
<div class="main">
<h2>买下Steam所有游戏需要</h2>
<h1>${dollar} 或 ¥{cny}</h1>
<h4>共有</h4>
<h1>{us_number}(美区),{cn_number}(中区)个游戏和 DLC!</h1>
<p class="date">更新日期:{date}</p>
<p><a href="http://numbbbbb.com/2016/02/15/20160215_%E5%A6%82%E4%BD%95%E8%AE%A1%E7%AE%97%20Steam%20%E6%B8%B8%E6%88%8F%E6%80%BB%E4%BB%B7%EF%BC%9F/">原理详解</a></p>
</div>
<div class="footer">
<span>
<a href="http://numbbbbb.com">作者@梁杰_numbbbbb</a>
</span>
</div>
</body>
</html>

注意到里面有几个奇怪的东西,那些是占位符,Node.js 中会读取 Python 执行出来的结果并替换掉,用户看到的网页显示的是实际数字。

你可以根据自己的喜好调整页面样式。

编写一个 Node.js 程序

首先配置好 Node.js 环境以及 npm,不会的自行 Google。

这里用到了hapi,一个 Node.js 服务端框架,专门用来处理网络请求。还用到了pm2,你可以把它理解成一个监控程序,它会帮你监控进程是否正常运行,并在必要的时候重启进程,这样你的服务就不会轻易狗带。我喜欢 ES6,所以需要安装babel-cli

1
2
$ sudo npm install pm2 babel-cli -g
$ sudo npm install hapi

由于babel-clipm2都需要执行命令行命令,所以全局安装。

下面创建 Node.js 程序:

1
2
$ touch index.js
$ vim index.js

拷贝进去下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/env babel-node
import Hapi from 'hapi'
import fs from 'fs'

let server = new Hapi.Server()
server.connection({
port: 3003,
routes: {
cors: {
origin: ['*']
}
}
})

function numberWithCommas(x) {
var parts = x.toString().split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
}

server.route({
method: 'GET',
path: '/',
handler: (request, reply) => {
fs.readFile("finalResult", (err, data) => {
if (err) throw err
let rawData = data.toString().split('\n')
fs.stat("finalResult", (err, data) => {
let mtime = data.mtime
fs.readFile("index.html", (err, data) => {
var result = data.toString()
result = result.replace("{dollar}", numberWithCommas(parseInt(rawData[1]) / 100))
result = result.replace("{cny}", numberWithCommas(parseInt(rawData[4]) / 100))
result = result.replace("{us_number}", numberWithCommas(rawData[2]))
result = result.replace("{cn_number}", numberWithCommas(rawData[5]))
result = result.replace("{date}", mtime.toISOString())
reply(result).code(200)
})
})
})
}
})

server.start((err) => {
console.log(err)
console.log('Server running at:', server.info.uri)
})

再次重复,本文的代码不包含任何最佳实践,Just For Fun!

这段代码很简单,启动一个服务器监听 3003 端口,如果有请求过来,就直接读取上面的 HTML 文件,用最新的数据替换掉 HTML 中的占位符,然后返回。

配置 Nginx

在 VPS 上安装和配置 Nginx。别问我怎么安装,问 Google。

打开配置文件:

1
$ vim /etc/nginx/nginx.conf

添加一段内容:

1
server {
  listen 80;
  server_name steamtuhao.com www.steamtuhao;    # ←写你的域名

  location / {
    proxy_pass http://127.0.0.1:3003;    # ←写你的端口
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;      
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
  }
}

注意两个地方,一个是域名,一个是端口。

当然,我们还没说到域名,先往下翻,看域名那一节,搞定域名再来这里配置。

写完之后重启 Nginx:

1
$ service nginx restart

看到输出[OK]就表示重启成功,配置没问题。如果不写域名这里会出错。

别问我标题什么意思,反正看 Link 也能猜到,就是把各个部分连接起来。

现在已经有了:

  • 域名
  • VPS
  • Nginx
  • HTML 页面
  • Node.js 程序

并且域名已经解析到 VPS、Nginx 已经配置好,只差最后一步,用pm2运行你的 Node.js 程序。

1
$ pm2 start index.js --interpreter babel-node

由于我使用了 ES6,所以要把解释器设置成babel-node

执行完这一步就可以了,现在用户可以访问你的 URL,请求会被发送到 VPS,VPS 上的 Nginx 接收到请求之后会转发给 Node.js 程序,这个程序会读取数字、替换占位符并返回最终的 HTML。

好了,展示部分已经搞定。下面还有最后一个任务:自动更新数据。

Final Round!

首先来修改我们的计算脚本,让它把美元总价人民币总价游戏和 DLC 总数以及修改日期写入finalResult文件,一个一行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from steamapiwrapper.SteamGames import Games
games = Games()
us_full_price = 0
us_discounted_price = 0
us_gameTotal = 0
for game in games.get_all('US'):
if game['initial'] != 0:
us_gameTotal += 1
us_discounted_price += game['final']
us_full_price += game['initial']

cn_full_price = 0
cn_discounted_price = 0
cn_gameTotal = 0
for game in games.get_all('CN'):
if game['initial'] != 0:
cn_gameTotal += 1
cn_discounted_price += game['final']
cn_full_price += game['initial']

print "\n".join([str(us_full_price), str(us_discounted_price), str(us_gameTotal), str(cn_full_price), str(cn_discounted_price), str(cn_gameTotal)])

我承认上面的代码很蠢,或许下一个版本我会重构,现在嘛,Just For Fun!

分别计算美元和人民币的价格,然后输出。注意输出顺序要和前面的 Node.js 程序对应。

最后写一个 Linux 的 crontab 命令,每天半夜 12 点自动执行一遍这个程序:

1
2
3
$ crontab -e
# 执行之后会打开一个文件,在文件倒数第二行写入以下内容
0 23 * * * cd /steamtuhao && python calTotalPrices.py > result && mv finalResult finalResult.bak && mv result finalResult

这里有个坑,注意,是写到倒数第二行,这个文件结尾必须有一个空行!如果写到最后一行无法执行。

是不是很奇怪?我个人认为这是 Linux 的一个脑残之处。执行man crontab,手册中有一行:

1
cron requires that each entry in a crontab end in a newline character. 
If the last entry in a crontab is missing the newline,
cron  will  consider the crontab (at least partially) broken and refuse to install it.

这句话的意思是说:最后一行必须是空行,否则最后一个任务无法执行。

没有任何解释,反正就是无法执行。难以想象,一个 21 世纪的 Linux 系统居然连空行问题都处理不了!

无论如何,一定要记住,crontab 文件结尾必须有空行

好了,现在你已经完成了所有步骤,把域名发给你的朋友吧!

总结

早晨开始写代码,中午开始写博客,这一切都在一天之内搞定。再次重申,文章中的代码并不好,因为代码本来就不是重点,重点是这个过程带给了我很多乐趣!

我一直觉得编程和写作、绘画一样,是一种创造的过程。我喜欢编程,我可以用它实现我的各种奇思妙想,我很享受这个过程。

希望你也能享受编程。

关于我

这一部分是最不重要的,因此放在最后。

如果你真的读到了这里,想必对这个喋喋不休的家伙有些兴趣。

我是梁杰,90 后,职业前端,业余 iOS,业余 Python,翻译过多本书。如果你想了解更多信息,可以访问以下链接:

  • 我的博客,最近才开始重建,内容不多,不过绝对有趣
  • GitHub,或许你点开就会发现“哦原来是你!”

开源?

当然会开源啦!

不过这个项目的代码太丑,开源出去只会误导新手,所以暂时不开。如果我还有精力和兴趣,会重构甚至重写这个项目,不过现在嘛……我要去休息了。